package org.upfrost;

import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.sql.SQLException;
import java.util.Collection;
import java.util.LinkedList;
import java.util.List;

import org.upfrost.cache.QueryCache;
import org.upfrost.idiom.SQLIdiom;
import org.upfrost.mapping.ClassProxy;
import org.upfrost.mapping.Mapping;

/**
 * A Base class for the DAO classes. Although this is not required, DAO provide a handy methods for mapped object persistence. 
 * 
 * @author Rodrigo Reyes
 *
 * @param <T>
 */
public class AbstractDAO<T> implements ICacheManager 
{
	static public long DEFAULT_CACHE_EXPIRATION_DELAY_MILLIS = 1000*60*60; // 1h
	
    private Class<T> m_persistentClass;
    private MapperSession m_session;
    private ClassProxy<T> m_proxy; 

    // 
    
    public AbstractDAO(MapperSession session, Class<T> persistentClass)
    {
    	m_persistentClass = persistentClass;
    	m_proxy = (ClassProxy<T>)session.getMapper().getClassProxy(m_persistentClass);
    	m_session = session;

    	if (m_proxy == null)
    		throw new RuntimeException("Class " + m_persistentClass + " is not known to the mapper");
    	
    	if (m_proxy.getTableName() == null)
    		throw new RuntimeException("Class " + m_persistentClass + " is not associated to an sql table");
    }
    
    /**
     * Creates a new DAO instance, using the T parameter as target class.
     * @param session
     */
    public AbstractDAO(MapperSession session)
    {
    	Type type = (Type) this.getClass().getGenericSuperclass();
    	ParameterizedType pt = (ParameterizedType)type;
    	Type tt = pt.getActualTypeArguments()[0];
    	m_persistentClass = (Class<T>)tt;
    	m_proxy = session.getMapper().getClassProxy(m_persistentClass);
    	m_session = session;
    	
    	if (m_proxy == null)
    		throw new RuntimeException("Class " + m_persistentClass + " is not known to the mapper");
    	
    	if (m_proxy.getTableName() == null)
    		throw new RuntimeException("Class " + m_persistentClass + " is not associated to an sql table");
    }

    static private String[] EMPTY_STRING_LIST = new String[0];
    
    /**
     * Create a Query object for the mapped class managed by this DAO object. This query is an SQL SELECT with a where clause
     * containing the java properties passed as parameters. Each java property is replaced by its sql column equivalent, and the resulting
     * query is ready to be used (but the parameters must be set up with a call to the param() method).
     * 
     * @param properties an array of properties from the mapped java object
     * @return a Query
     */
    protected Query<T> queryByProperties(String... properties)
    {
    	if (properties == null)
    		properties = EMPTY_STRING_LIST;
    	
    	String strQuery = m_session.getMapper().getQueryCache().read(this, properties); 
    	if (strQuery == null)
    	{
    		this.getSession().getMapper().getStatistics().incQueryCacheMiss();
    		strQuery = this.createSelectByProperties(properties);
    		m_session.getMapper().getQueryCache().write(this, strQuery, properties);
    	}
    	else
    	{
    		this.getSession().getMapper().getStatistics().incQueryCacheHit();
    	}
    	return this.createQuery(strQuery).setDefaultTargetClass(m_persistentClass);
    }

    /**
     * Create an SQL SELECT statement filling the where-clause with the sql columns of the mapped java object properties passed as parameters. 
     * @param properties
     * @return
     */
    private String createSelectByProperties(String... properties)
    {
    	SQLIdiom idiom = m_session.getMapper().getIdiom();
    	
    	LinkedList<String> columns = new LinkedList<String>();
    	
    	int index = 0;
    	for (String p: properties)
    	{
    		Mapping propmap = m_proxy.getPropertyMapping(p);
    		if (propmap == null)
    			throw new RuntimeException("No property '" + p +"' for " + m_proxy.getMappedClass());
    		if (propmap.getSqlName() == null || propmap.getSqlName().length()==0)
    			throw new RuntimeException("No sql column defined for property '" + p +"' for " + m_proxy.getMappedClass());

    		columns.add(propmap.getSqlName());
    	}
    	
    	return idiom.createSelectStatement(this.getProxy().getTableName(), null, columns, null, null);
    }

    /**
     * Query the mapped object T from the database using a key object as parameter. Obviously, this requires the mapped object 
     * to have a single identified key (@Id annotation). If there is no @Id set for the mapped object, or if there is more 
     * than one key for this object, the method raises an exception. 
     * <p/> This method is only for the most generic case of mapped object with an identity key, for other case you have to write a 
     * specific sql query.
     * @param key
     * @return
     */
    public T get(Object key)
    {
    	if (m_proxy.hasKey() == false)
    		throw new RuntimeException("Class " + m_persistentClass + " has no identified ID key");
    	if (m_proxy.hasOneKey() == false)
    		throw new RuntimeException("Class " + m_persistentClass + " has no more than one ID key");
    	
    	Mapping keyMapping = m_proxy.getIds()[0];

    	try {
    		Query<T> q = this.queryByProperties(keyMapping.getName()).param(key).asClass(m_persistentClass);
    		T result = q.single();
    		q.clear();
    		return result;
//    		return this.createQuery("select * from " + m_proxy.getTableName() + " where " + keyMapping.getSqlName() + "=?").param(key).as(m_persistentClass).single();
    	} catch (Exception exc)
    	{
    		throw new RuntimeException("Invalid get query for " + m_proxy, exc);
    	}
    }

    /**
     * Either insert a new object, or update it if it already exists in the database. This method tries to be smart
     * by guessing if the object is already in the database by checking the @Id key. If there is no identity key
     * this method raises an exception. If the guess is wrong, the method catches the error and tries again with a better
     * guess.
     * 
     * @param object the mapped object to insert or update in the database
     * @return
     */
    public int insertOrUpdate(T object)
    {
    	if (m_proxy.hasKey() == false)
    		throw new RuntimeException("Class " + m_persistentClass + " has no identified ID key");
    	
    	boolean insertFirst = false;

    	for (Mapping keyMapping: m_proxy.getIds())
    	{
        	Object keyValue = keyMapping.get(object);
        	// if (m_proxy.isIdGenerated() && (m_session.getMapper().isIdSet(keyValue)==false))
        	if (m_session.getMapper().isIdSet(keyValue)==false)
        	{
        		insertFirst = true;
        		break;
        	}
    	}
    	
    	if (insertFirst)
    	{
    		int rowsAffected = 0;
    		try {
    			rowsAffected = insert(object);
    		} catch (Exception exc)
    		{
    			// do nothing
    		}
    		
    		if (rowsAffected == 0)
    			return update(object);
    		else
    			return rowsAffected;
    	}
    	else
    	{
    		int rowsAffected = 0;
    		try {
    			rowsAffected = update(object);
    		} catch (Exception exc)
    		{
    			// do nothing
    		}
    		
    		if (rowsAffected == 0)
    			return insert(object);
    		else 
    			return rowsAffected;
    	}
    }
    
    /**
     * Insert an object in the database. If an identity key with a generated value (@Id and @GeneratedValue) exists
     * for the object, it is set up after the insertion.
     * @param object the object to insert
     * @return 1 if the object was correctly saved, 0 otherwise
     */
    public int insert(T object)
    {
    	boolean ommitKey = m_proxy.hasOneKey() && m_proxy.isKeyGenerated(m_session.getMapper(), object);
    	List<String> columns = m_proxy.getColumns(ommitKey);
    	
    	String query = readCachedDefaultQuery("insert");
    	if (query == null)
    	{
    		SQLIdiom idiom = m_session.getMapper().getIdiom();
    		query = idiom.createInsertStatement(m_proxy.getTableName(), columns);
    	}
    	Query<T> q = this.createQuery(query);
    	
    	for (String col: columns)
    	{
    		Mapping map = m_proxy.getMappingFromSqlColumn(col);
    		Object o = map.get(object);
    		q.param(o);
    	}
    	
    	int res = q.update();
    	
    	if (res == 1 && ommitKey && m_proxy.hasOneKey())
    	{
    		Object key = q.getGeneratedKey();
    		// If there's a key, we update the object id with the possibly newly created identity
    		m_proxy.getIds()[0].set(object, key);
    	}
    	q.clear();
    	return res;
    }

    /**
     * Update an object in the database
     * @param object the mapped object to update
     * @return 1 if the object was correctly updated, 0 otherwise
     */
    public int update(T object)
    {
    	if (m_proxy.hasKey() == false)
    		throw new RuntimeException("Class " + m_persistentClass + " has no identified ID key");


    	List<String> columns = m_proxy.getColumns(true); // ommit the key, because we won't update it
    	
    	Mapping[] keys = m_proxy.getIds();
    	LinkedList<String> keylist = new LinkedList<String>();
    	for (Mapping m: keys)
    	{
    		keylist.add(m.getSqlName());
    	}

    	String query = readCachedDefaultQuery("update");
    	if (query == null)
    	{
        	SQLIdiom idiom = m_session.getMapper().getIdiom();
        	query = idiom.createUpdateStatement(m_proxy.getTableName(), columns, keylist);
    	}

    	Query<T> q = this.createQuery(query);
    	
    	for (String col: columns)
    	{
    		Mapping map = m_proxy.getMappingFromSqlColumn(col);
    		Object o = map.get(object);
    		q.param(o);
    	}
    	
    	for (Mapping km : keys)
    	{
    		q.param(km.get(object));
    	}
    	
    	int res = q.update();
    	q.clear();
    	
    	return res;
    }

    /**
     * Update an object identified by a set of properties. The SQL where-clause is constructed using the mapped java object properties
     * passed as parameters, and the values for each of those columns is set using the values of the java object. 
     * 
     * @param object an object to update
     * @param propertyKeys the java property names that identify the objects
     * @return 1 if the object was correctly updated, 0 otherwise
     */
    public int update(T object, String... propertyKeys)
    {
    	Mapping<?>[] keyMapping = new Mapping[propertyKeys.length];
    	LinkedList<String> sqlKeys = new LinkedList<String>();
    	int index = 0;
    	for (String key: propertyKeys)
    	{
    		Mapping<?> map = m_proxy.getPropertyMapping(key);
    		keyMapping[index++] = map;
    		sqlKeys.add(map.getSqlName());
    	}

    	SQLIdiom idiom = m_session.getMapper().getIdiom();
    	
    	List<String> columns = m_proxy.getColumnsWithoutProperties(propertyKeys);
    	Query<T> q = this.createQuery(idiom.createUpdateStatement(m_proxy.getTableName(), columns, sqlKeys));
    	
    	for (String col: columns)
    	{
    		Mapping<?> map = m_proxy.getMappingFromSqlColumn(col);
    		Object o = map.get(object);
    		q.param(o);
    	}

    	for (Mapping<?> map : keyMapping)
    	{
    	   	q.param(map.get(object));
    	}
    	
    	int res = q.update();
    	
    	q.clear();

    	return res;
    }


    /**
     * Delete a mapped object from the database. this requires the mapped object to have at least one identity key (@Id annotation). 
     * If there is no @Id set for the mapped object the method raises an exception. 
     * @param object
     */
    public void delete(T object)
    {
//    	Mapping keyMapping = m_proxy.getId();
//    	if (keyMapping == null)
//    		throw new RuntimeException("Class " + m_persistentClass + " has no identified ID key");

    	if (m_proxy.hasKey() == false)
    		throw new RuntimeException("Class " + m_persistentClass + " has no identified ID key");

    	SQLIdiom idiom = m_session.getMapper().getIdiom();
    	
    	Mapping[] keys = m_proxy.getIds();
    	LinkedList<String> keylist = new LinkedList<String>();
    	for (Mapping m: keys)
    	{
    		keylist.add(m.getSqlName());
    	}

    	Query<T> q = this.createQuery(idiom.createDeleteStatement(m_proxy.getTableName(), keylist, null));
    	
    	for (Mapping km: m_proxy.getIds())
    	{
        	Object key = km.get(object);
        	q.param(key);
    	}
    	
    	int res = q.update();
    	q.clear();
    	if (res == 0)
    		throw new RuntimeException("No object deleted");
    	
    	if (res > 1)
    		throw new RuntimeException("Delete more than one object (" + res + ")");
    }    
    
    static private String join(Collection<String> elements, boolean quote)
    {
    	StringBuffer result = new StringBuffer();
    	int index = 0;
    	for (String col: elements)
    	{
    		if (index++>0)
    			result.append(",");

    		result.append(col);
    	}    	
    	return result.toString();
    }
    
	/**
	 * If the session autocommit is set to false, this commits the changes.
	 * @throws SQLException
	 */
    public void commit() throws SQLException
    {
    	m_session.commit();
    }
    
	/**
	 * If the session autocommit is set to false, calling this method cancels the updates created since the last call to commit or rollback, or the creation of the session.
	 * @throws SQLException
	 */
    public void rollback() throws SQLException
    {
    	m_session.rollback();
    }
    
	/**
	 * Configure the autocommit property of connections used by this session 
	 * @param b true to enable the autocommit, false otherwise.
	 * @throws SQLException
	 */
    public void setAutoCommit(boolean b) throws SQLException
    {
    	m_session.setAutoCommit(b);
    }

    /**
     * Get the mapped object class that is managed by this DAO.
     * @return the class of the mapped object
     */
	public Class<T> getPersistentClass() {
		return m_persistentClass;
	}

	/**
	 * Get the session used by this DAO instance.
	 * @return a session instance
	 */
	public MapperSession getSession() {
		return m_session;
	}

	/**
	 * Get the mapped object proxy ClassProxy used by this DAO instance.
	 * @return a ClassProxy instance.
	 */
	public ClassProxy<T> getProxy() {
		return m_proxy;
	}

	/**
	 * Create a Query object for an sql query.
	 * 
	 * @param query
	 * @return
	 */
	public Query<T> createQuery(String query)
	{
		if (isUsingCache())
			return (Query<T>) this.getSession().createQuery(query, this);
		else
			return (Query<T>) this.getSession().createQuery(query, null);
	}

	public boolean isUsingCache()
	{
		return true;
	}
	
	public int getCacheSize()
	{
		return 100;
	}

	public long getCacheExpirationDelayMillis()
	{
		return DEFAULT_CACHE_EXPIRATION_DELAY_MILLIS; // That's 1h
	}

	public Object readCache(String query, Object...params)
	{
		return m_session.readCache(this, query, params);
	}
	
	public void writeCache(Object result, String query, Object...params)
	{
		m_session.writeCache(this, result, query, params);
	}
	
	public void clearCache()
	{
		m_session.clearCache(this);
	}

	private String readCachedDefaultQuery(String verb)
	{
		return m_session.getMapper().getQueryCache().read(this, this.getClass().getCanonicalName()+"::"+verb);
	}

	private void writeCachedDefaultQuery(String verb, String result)
	{
		m_session.getMapper().getQueryCache().write(this, result, this.getClass().getCanonicalName()+"::"+verb);
	}
	
}
